查看原文
其他

Python 实战 | 进阶中文分词之 HanLP 词典分词(上)

Python实战 数据Seminar 2024-03-13


Python教学专栏,旨在为初学者提供系统、全面的Python编程学习体验。通过逐步讲解Python基础语言和编程逻辑,结合实操案例,让小白也能轻松搞懂Python!

>>>点击此处查看往期Python教学内容

本文目录

一、引言 
二、加载 HanLP 词典 
三、切分规则
四、实现 HanLP 词典分词 
五、结束语
本文共9395个字,阅读大约需要24分钟,欢迎指正!

Part1引言

自然语言处理任务的层次可以分为词法分析、句法分析和语义分析,同时这也是从易到难的递进过程。对中文来说,词法分析(中文分词、词性标注、命名实体识别)是后续任务的基础,而中文分词又是其中最基本的任务。目前中文分词算法大致可以分为基于词典规则与基于机器学习两大派别,无论是哪个派别的算法总有各自的优缺点,我们在工作学习中应该选择最适合当前任务的算法。

本期将为大家介绍如何基于 HanLP 进行词典分词,词典分词是一种基于词典库的分词方法,它的原理是将待处理的文本与词典中的词语进行匹配,找出最长的匹配词并切分。另外,为什么我们先介绍 HanLP 词典分词呢?除了其原理相对容易理解,介绍该方法还有以下两点原因:

  1. HanLP 支持自定义词典(同时也支持设置默认的词典词性),用户可以针对各自领域的内容自定义词典,以此提高分词的精度。
  2. HanLP 开发者何晗(hancks)使用双数组字典树结构来存储字典,具有更小的空间复杂度和更高的匹配效率,在保证精度的同时,提高了分词的效率。

根据词典分词的定义,使用词典来分词其实仅需要一部词典和一套查找词典的规则即可,于是下面我们会先介绍在 HanLP 中如何加载词典,然后介绍三种常用的切分规则以及它们的效果,最后实现 HanLP 的词典分词。

文本基于 HanLP 1.8.4 版本书写。

本文中所有 Python 代码均在集成开发环境 Visual Studio Code (VScode) 中使用交互式开发环境 Jupyter Notebook 中编写。

Part2加载 HanLP 词典

实现词典分词的第一步,就是准备一部词典。网上已经有许多公开的中文词库了,比如 THUOCL(清华大学开放中文词库)[1]中文维基百科抽取的词库[2]和何晗发布的千万级巨型汉语词库[3]等等,有需要的读者可以自行下载,供个人研究学习使用。

上期文章我们已经介绍了安装 HanLP 的方法,如果未安装的读者请参考Python 实战 | 文本分析工具之HanLP入门。在我们第一次运行时,HanLP 自带的数据包和字典就会自动下载到pyhanlp的系统路径中,笔者的词典路径如下:

C:\Users\QIYAN_USER\miniconda3\Lib\site-packages\pyhanlp\static\data\dictionary

这里以 HanLP 自带的核心词典(上图 CoreNatureDictionary.txt)为例,这是一个“utf-8”编码的纯文本文件,使用记事本打开格式如下:

可以看到,HanLP 词典是以空格作为分隔的表格形式,这三列分别为词语、词性和相应的词频(在某个语料库的统计结果),比如 “叙述” 这个词以动词的形式出现了 72 次、以动名词的形式出现了 18 次。

现在我们来看如何在 HanLP 中加载这份词典。代码如下:

要注意一点,为了便于展示,本文将代码存放在.ipynb文件中。如果读者后续有调用该函数的需求,建议将代码存于扩展名为.py的脚本文件(也称“模块”)中,然后在需要使用的模块中导入该模块,即可随时调用该模块中的函数。

from pyhanlp import *

# 参数 path:需要加载的词典路径
def load_dictionary(path):
    IOUtil = JClass('com.hankcs.hanlp.corpus.io.IOUtil')  # 1
    dic = IOUtil.loadDictionary([path])                   # 2
    return set(dic.keySet())  # 返回 set 形式的词典

my_dict = load_dictionary(HanLP.Config.CoreDictionaryPath)  # 传入核心词典路径
print(len(my_dict))  # 词典的词条数

# 运行结果
'''
153091
'
''
上述代码中,注释为 1 的这行代码中 JClass 函数是用来根据 Java 路径名得到一个 Python 类的桥梁,该行代码的作用是获取 HanLP 中的工具类 IOUtil(其主要功能是进行输入输出操作,如读写文件等);注释为 2 的这行代码是调用了 IOUtil 的方法loadDictionary,该方法支持将多个文件读入同一个词典中,所以需要传入一个 list,返回值 dic 是一个 TreeMap,它的键是词语本身,值是一个包含词性和词频的结构(暂时不用管)。在中文分词中,我们更关心词语本身,所以函数只需返回 TreeMap 的键(通过dic.keySet())即可。

然后我们将 HanLP 的配置项 Config 中的词典路径作为参数传入,得到了词典my_dic,并且输出了词典的词条数量。至此,我们已经成功加载 HanLP 的核心词典,后续就可以基于 Python 代码使用该词典了。

如果现在需要使用 HanLP 的核心迷你词典呢?只要将调用函数的代码改为:
my_dict = load_dictionary(HanLP.Config.CoreDictionaryPath.replace('.txt''.mini.txt'))

Part3切分规则

准备好词典之后,下一步就需要确定查找词典的规则。常用的三种规则为正向最长匹配、逆向最长匹配和双向最长匹配,在具体了解这三种切分规则之前,首先需要对完全切分有一个认识,因为这是三种切分规则的基础。

完全切分指的是找出一段文本中所有的单词,请注意,这不是标准意义上的分词,完全切分做的就是遍历文本中的连续序列,并查询这个序列是否在词典中。根据这个思想,现在我们切分“完全切分过程是切分规则的基础”这句话,代码如下:

def full_seg(text, dic):
    seg_list = []
    for i in range(len(text)):             # i 从 0 遍历至 text 最后一个字的下标
        for j in range(i+1, len(text)+1):  # j 从 i 的后一个位置开始遍历
            word = text[i:j]               # 取出区间 [i, j) 对应的字符串
            if word in dic:                # 如果该字符串在词典中,则认为是一个词语
                seg_list.append(word)
    return seg_list
# 此处使用的词典,为上一步加载的 HanLP 核心词典
full_seg('完全切分过程是切分规则的基础', my_dict)
结果如下:
可以看到,完全切分将所有可能的词按遍历的顺序全部输出了,包括一些词语和单字。这一定不是我们想要的分词结果,考虑到越长得到单词表达的含义越丰富,于是定义单词越长优先级越高。这时三种切分规则就出现了,在以某个下标为起点递增查词的过程中,优先输出更长的词语,这种规则就是最长匹配算法,其中包括正向、逆向和双向最长匹配。

1正向最长匹配

顾名思义,正向匹配算法就是下标的扫描顺序在递增查词的过程中是从左到右的。简单来说,它会从左到右开始扫描待分词的文本,每次都尝试匹配尽可能长的词语,如果找到匹配的词语,就将其作为一个词切分出来,然后从下一个字符开始继续匹配。来看一下效果,现在我们切分“这项研究在中国人民大学进行”这句话,代码如下:
def fore_seg(text, dic):
    seg_list = []
    i = 0
    while i < len(text):
        longest_word = text[i]                   # 当前扫描位置对应的字符
        for j in range(i+1, len(text)+1):        # j 为结束为止,从 i 的下一位开始,遍历得到所有可能的词 [i,j)
            word = text[i:j]                     # 得到区间 [i,j) 对应的字符串
            if word in dic:                         
                if len(word) > len(longest_word): # 该词在词典中 & 比longest_word更长,优先输出
                    longest_word = word
        seg_list.append(longest_word)             # 输出每一个起始位置 i 匹配的最长词
        i += len(longest_word)                    # 从下一个字符开始匹配
    return seg_list

fore_seg('这项研究在中国人民大学进行', my_dict)

# 输出结果
'''
['
这项', '研究', '', '中国人', '', '大学', '进行']
'
''
可以看到分词结果将“中国人”作为一个词语切分出来了,实际上,对于这句话,我们理想的切分结果是有“中国”和“人民”两个词语。这是因为正向最长匹配认为“中国人”的优先级比“中国”更高。那么如果这句话用逆向最长匹配的规则来切分,会得到什么结果呢?

2逆向最长匹配

逆向最长匹配与正向最长匹配的区别在于,它会从右到左开始扫描待分词的文本。此时只需对代码做出相应的更改即可,如下:
def back_seg(text, dic):
    seg_list = []
    j = len(text) - 1
    while j >= 0:                  # 逆向扫描, 当前扫描位置为终点
        longest_word = text[j]     # 当前扫描位置对应的字符
        for i in range(0, j):      # i 为起始位置,从 0 开始遍历至 j 的前一个位置
            word = text[i: j+1]    # 得到区间 [i, j] 对应的字符串
            if word in dic:
                if len(word) > len(longest_word): # 该词在词典中 & 比longest_word更长,优先级更高
                    longest_word = word
        seg_list.insert(0, longest_word)          # 由于逆向扫描,查出的单词位置靠后
        j -= len(longest_word)
    return seg_list

back_seg('这项研究在中国人民大学进行', my_dict)

# 输出结果
'''
['
这项', '研究', '', '中国', '人民', '大学', '进行']
'
''

可以看到,这句话的分词结果就是我们想要的,这可以说明逆向最长匹配的效果比正向最长匹配的效果更好吗?那么再看另一句话的分词结果,代码如下:

print(fore_seg('项目的研究目的值得人们的关注', my_dict))
print(back_seg('项目的研究目的值得人们的关注', my_dict))

# 输出结果
'''
['
项目', '', '研究', '目的', '值得', '人们', '', '关注']

['
', '目的', '研究', '目的', '值得', '人们', '', '关注']
'
''
针对上例中的这句话,正向最长匹配的结果是优于逆向最长匹配的,可见这两个切分规则是各有千秋。

3双向最长匹配

于是有人就提出了综合这两个规则的一个切分规则——双向最长匹配,它的规则更加复杂一些:

  1. 同时进行正向、逆向最长匹配,如果两个分词结果词数不同,则返回词数更少的结果
  2. 如果两个分词结果词数相同,则返回两者中单字更少的结果
  3. 如果单字的数量也相同,优先返回逆向最长匹配的结果


💡 据 SunM.S. 和 Benjamin K.T.(1995)的研究表明,中文中 90.0% 左右的句子,正向最大匹配和逆向最大匹配完全重合且正确,只有大约9.0%的句子两种切分规则得到的结果不一样,但其中必有一个是正确的(歧义检测成功),只有不到1.0%的句子,是正向最大匹配和逆向最大匹配的切分虽重合却是错的,或者正向最大匹配法和逆向最大匹配法切分不同但两个都不对(歧义检测失败)。

结合双向最长匹配规则,得到如下代码:
def bidir_seg(text, dic):
    fore = fore_seg(text, dic)
    back = back_seg(text, dic)
    bidir = fore if  len(fore) < len(back) else back   # 1.选择词数更少的结果
    
    count_fore = len([word for word in fore if len(word) == 1])  # 正向匹配结果中单字的数量
    count_back = len([word for word in back if len(word) == 1])  # 逆向匹配结果中单字的数量
    bidir = fore if count_fore < count_back else back  # 2.3.选择单字更少的结果,如果相等,优先逆向匹配
    return bidir
 
print(bidir_seg('这项研究在中国人民大学进行', my_dict))
print(bidir_seg('项目的研究目的值得人们的关注', my_dict))

# 输出结果
'''
['
这项', '研究', '', '中国', '人民', '大学', '进行']

['
', '目的', '研究', '目的', '值得', '人们', '', '关注']
'
''
在双向最长匹配的切分规则下,两句话的分词结果其实都是逆向最长匹配得到的,第一句的分词结果是正确的,而第二句分词结果是不理想的。

不难看出,词典分词的这三个切分规则都没有完美的效果,消歧效果不够好,无法确保规则正确理解每个词在特定上下文中的具体含义,分词结果很大程度上取决于词典的精确程度。当然,HanLP 的作者也指出词典分词的核心价值不在于精度,在于速度。

Part4实现 HanLP 词典分词

匹配算法的瓶颈之一在于如何判断字符串在词典中,中文有 7000 余个常用字,56000 余个常用词,将这些数据加载到内存中很容易,但是进行高并发毫秒级运算就比较困难了,这时就需要设计巧妙的数据结构和存储方式。在基于 Java 的高性能分词器 HanLP 中,作者使用双数组完成了字典树(Trie 树)和自动机的存储,实现了更快速度的匹配。本文更侧重于实战,读者如果对具体的算法原理很感兴趣,可以在何晗的《自然语言处理入门》这本书中学习。
一般情况下,当我们的待切分文本含有短模式的字符串(词语都不算长或者有单字)时,优先使用双数组字典树(DAT)这个数据结构,否则,优先使用基于双数组字典树的 AC 自动机(ACDAT)这个数据结构。下面我们就来看看如何调用 HanLP 的词典分词器。
HanLP 中的词典分词(DictionaryBasedSegment 类)包含 DAT 分词器(DoubleArrayTrieSegment 类)和 ACDAT 分词器(AhoCorasickDoubleArrayTrieSegment 类),由于我们的待分词文本不是长文本,这里仅以 DAT 分词器为例来介绍。
DAT 分词器是对 DAT 最长匹配的封装,分词器默认加载的是我们在上文介绍的 HanLP 的核心词典,代码如下:
# 关闭词性标注(上期文章已介绍)
HanLP.Config.ShowTermNature = False
# 实例化
segment = DoubleArrayTrieSegment()
print(segment.seg("语料库规模决定实际效果,面向生产环境的语料库应当在千万字量级。"))

# 输出结果
'''
[语料库, 规模, 决定, 实际, 效果, ,, 面向, 生产, 环境, 的, 语料库, 应当, 在, 千, 万, 字, 量级, 。]
'
''
第二行代码是创建了一个 DoubleArrayTrieSegment 类的实例,这是因为 DoubleArrayTrieSegment 是 HanLP 中的一个类,为了使用类中定义的属性与方法,需要先创建它的一个实例(如果对类和实例不太了解的读者,可以参考文章Python 教学 | 一文搞懂面向对象中的“类和实例”)。
我们也可以向分词器传入自己的词典路径,比如我们将词典路径中名为全国地名大全.txtCustomDictionary.txt的词典传入,代码如下:
# 需要传入的词典路径
dict1 = HANLP_DATA_PATH + "/dictionary/custom/全国地名大全.txt ns"
dict2 = HANLP_DATA_PATH + "/dictionary/CoreNatureDictionary.mini.txt"
# 以 list 的形式,将多个词典传入
segment = DoubleArrayTrieSegment([dict1, dict2])
print(segment.seg('中国北京市海淀区中关村大街59号'))

# 输出结果
'''
[中国, 北京市, 海淀区, 中关村, 大街, 5, 9, 号]
'
''
上述代码中,HANLP_DATA_PATH指向 pyhanlp 的数据包路径,所以只需在后面添加词典路径即可;同时,分词器支持传入多个词典(以 list 形式)。另外,你可能注意到,dict1 的词典名为全国地名大全.txt ns,这里的ns指默认该词典中词语的词性为ns(地名),这就是我们在引言中说过的“词典级默认词性”,这样就不需要在词典中对每一个词条指定词性了,并且,你也可以对词典中单独的词条设置词性,词条所指定的词性的优先级大于词典的词性。
仔细查看分词结果可以发现,门牌号59应该是连在一起的,而不是拆开的。这是因为在 DictionaryBasedSegment 中,合并数字、英文以及词性标注是一个整体功能,开启该功能的代码如下:
# 开启合并与词性标注功能
segment.enablePartOfSpeechTagging(True)
print(segment.seg('中国北京市海淀区中关村大街59号'))

# 输出结果
'''
[中国/ns, 北京市/ns, 海淀区/ns, 中关村/ns, 大街/n, 59/m, 号/q]
'
''
需要注意一点,默认词典词性并不表示一句话所有的词语都是指定的词性,这显然是不对的。HanLP 的词性标注会考虑上下文和其他因素,比如这句话中“大街”这个词更适合名词,而不是一个地名,从上面的分词和词性标注结果也可以看出,这个功能是比较灵活的。

Part5结束语


💡 分词是中文自然语言处理的基础,没有中文分词,我们难以对语言进行量化。基于词典的分词算法是一种常见的中文分词方法,本文着重介绍了三种切分规则,以及在高性能分词器 HanLP 中实现词典分词。分词结果已经得到了,怎么来计算分词的准确率呢?下期文章我们主要将介绍在中文分词领域使用的准确率评价指标。下期再见~

另外,如果您也有关于文本分析的实操经验,欢迎给我们留言交流您使用的方法或工具,让我们一起探索更多的技术!


如果你想学习各种 Python 编程技巧,提升个人竞争力,那就加入我们的数据 Seminar 交流群吧,欢迎大家在社群内交流、探索、学习,一起进步!同时您也可以分享通过数据 Seminar 学到的技能以及得到的成果。

扫码联系客服
加入数据seminar-Python交流学习群

参考资料

[1]

THUOCL(清华大学开放中文词库: https://github.com/thunlp/THUOCL

[2]

中文维基百科抽取的词库: https://dumps.wikimedia.org/zhwiki/latest/

[3]

千万级巨型汉语词库: https://www.hankcs.com/nlp/corpus/tens-of-millions-of-giant-chinese-word-library-share.html

Part6相关推荐

Python 教学


 (向下滑动查看更多)

Python 实战

 (向下滑动查看更多)

数据可视化

 (向下滑动查看更多)



星标⭐我们不迷路!想要文章及时到,文末“在看”少不了!

点击搜索你感兴趣的内容吧

往期推荐


Python 实战 | 文本分析工具之HanLP入门

Python 实战 | 文本分析之文本关键词提取

Python 教学 | Python 学习路线+经验分享,新手必看!

数据资源 | 社科人必备的数据资源大全,建议收藏!

数据可视化 | 用 Python 制作动感十足的动态柱状图




数据Seminar




这里是大数据、分析技术与学术研究的三叉路口


推荐 | 青酱


    欢迎扫描👇二维码添加关注    

点击下方“阅读全文”了解更多
继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存